-
Notifications
You must be signed in to change notification settings - Fork 0
docs: #22 scalability measured at 100,000 and 1,000,000 albums #73
base: main
Are you sure you want to change the base?
Conversation
The database-backed suites were skipped in CI because nothing set TEST_DATABASE_URL, so "27 skipped" was passing for the whole database layer. The new `integration` job creates a throwaway PostgreSQL cluster inside the runner's temp directory, socket-only, applies the migrations, runs the server suite and deletes the cluster. No shared test database, no secret to hand out, and the application's `crate` database is never in reach. `deploy` now requires the job, so a deploy cannot ignore the database tests. The cluster alone would still have left the failure mode from "skipped" to "silently empty", so `server/scripts/assert-postgres-tests.mjs` reads the JSON report and fails when any database-backed file has zero executed or any pending assertions. Verified against both reports: with the database it prints the four files and exits 0, without it fails on the three suites that skip.
The first CI run failed with 'initdb: command not found' in 12 seconds: Debian puts the server binaries in /usr/lib/postgresql/<major>/bin and only the client on PATH, so PostgreSQL 18 being installed is not enough. Try PATH first, then the newest installed major version, and fail with a message that names the missing package if neither works.
Documents the "udefinert stor datasamling" requirement with numbers instead of an assertion, and records the limits honestly. New: `paging-perf.sql` compares the keyset page with OFFSET at a configurable depth; `index-perf.sql` runs the selective search with and without the GIN index, dropping it inside a transaction that is rolled back so it is always restored; `benchmark.sh` runs both and refuses any database whose name does not end in `_perf`, because the index comparison takes an exclusive lock on album. Findings worth keeping: the broad full-text query is planned as a sequential scan even with the index, and that is correct; the GIN index is worth 5-6x on a selective search; cursor paging is depth-independent while OFFSET reaches 1.2 s at 500,000 rows, but only becomes O(page) with a composite sort index, which the schema does not have yet. That is measured, explained and filed as a follow-up rather than added in a documentation issue.
51c80bf to
bbeb34d
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work on the scalability measurements! The results are useful, and I like that you've documented the limitations as well. I found a few things worth looking into before merging:
-
Paging benchmark: The benchmark uses a tuple comparison, while the API uses an
ORcondition. Even though these are logically equivalent, PostgreSQL may produce different execution plans, so the benchmark might not accurately represent the API's performance. The cursor and OFFSET queries also seem to be off by one, meaning they don't return exactly the same page. Could we align the queries with the API and make sure the comparison is consistent? -
Database safety: The benchmark script checks the database name in the URL, but connection parameters can override it. Could we also verify the actual connected database using
current_database()? This would make the safeguard more reliable. -
Reproduction instructions: The README sets
DATABASE_URL, but thepsqlcommands don't explicitly use it. The HTTP benchmark instructions could also clarify how to start the API against the performance database, so others can reproduce the measurements. -
Indexed paging results: The README includes results from an experiment with an additional composite index, but I couldn't find instructions for reproducing it. Could we include the index creation command and a representative query plan?
One small documentation suggestion as well: the new scalability section contains a lot of useful information, but I found it a bit difficult to identify the main results and conclusions among all the technical details. Maybe we could keep the key findings and a short explanation in the README, and move the more detailed experiments and measurements into a separate document under docs/, ex. scalability.md? That would make the README easier to navigate while keeping the technical evidence available.
Overall, the measurements and explanations are valuable. I think these adjustments would make the benchmarks easier to verify and the documentation easier to follow.
Two problems with paging-perf.sql, both from review by rachelks on #73. The cursor and OFFSET pages were one row apart. The anchor row was fetched with OFFSET :offset LIMIT 1, which is the row after the last one already seen, so the keyset query returned rows offset+2..offset+21 while the OFFSET query returned offset+1..offset+20. The anchor is now OFFSET :offset - 1, so both start at the same row. The script now proves it instead of asserting it: it materialises both pages and reports cursor_rows, offset_rows and shared_rows. A run at 500,000 rows prints 20 / 20 / 20, so the timings compare the same work. The keyset predicate is documented as the row comparison `(a, b) > (x, y)` that the API uses, with the reason it matters: PostgreSQL applies it as an index condition on a matching composite index, while the equivalent OR form is only a filter. That OR form was in crateForUser when this was written; #77 changed the API to the row comparison, so the benchmark and the API now run the same shape. offset is documented as "rows already scrolled past", which must be at least 1 because the first page has no cursor to page after. The script says so and stops with ON_ERROR_STOP instead of measuring a mislabelled page.
Review by rachelks on #73 asked how the (lower(title), id) numbers were produced, and whether the database name check could be bypassed. paging-index-perf.sql is the missing experiment: it creates the index inside a transaction, runs ANALYZE, measures the keyset page and the OFFSET page at the same depth as paging-perf.sql, and rolls the transaction back so the schema is unchanged. Measured at one million albums: 47 MB, cursor 0.11 ms against 180.09 ms for OFFSET at 500,000 rows, with the plan showing `Index Cond: (ROW(lower(title), id) > ROW(...))` and `Index Searches: 1`. benchmark.sh now runs it, and checks the real connection in addition to the name in the URL. libpq lets `?dbname=`, a service file or PGDATABASE point somewhere else, and a mistake there runs CREATE INDEX or DROP INDEX on the wrong database. It asks the server for current_database() and refuses anything not ending in _perf. Verified with `postgres:///crate_perf?host=...&dbname=crate_test`: the URL check passes, the server check fails, exit 1.
Review by rachelks on #73: the scalability section buried the conclusions in several screens of tables and plans. README keeps the headline table, five one-line conclusions and the pointer. The method, the full tables, the plans, the prefix-search numbers and the reproduction steps move to docs/scalability.md, which is linked from the README and from the scripts. The reproduction section is corrected as part of the move: the psql commands now pass "$DATABASE_URL" explicitly (psql does not read it on its own, which made the old snippet connect to the default database), and it shows how to build and start the API against the performance database for the HTTP numbers.
|
Thanks — all four were worth fixing. Pushed as 1. The two pages really were one row apart, and the tuple is now the API's form You are right on both counts. The anchor was fetched with Rather than assert that they match, the script now checks it: it materialises both pages and reports the counts before timing them. At 500,000 rows: On the tuple/OR difference: this PR branched before #77, and #77 changes added_at < $1 OR (added_at = $1 AND id < $2)to the row comparison (added_at, id) < ($1, $2)so the benchmark now matches the API. That was not cosmetic: the One correction to the depth parameter while I was in there: 2. Check the connection, not the URL Added. connected_db="$(psql "$DATABASE_URL" -X -tAc 'select current_database()')"Both must end in 3. Correct — pnpm --filter @t19/server build
PORT=3001 node server/dist/index.js &4. The indexed experiment is now reproducible New CREATE INDEX album_title_lower_id_perf_idx ON album (lower(title), id);At one million albums it reports 47 MB and, at 500,000 rows in:
Documentation split Agreed that the README was hard to navigate. It now keeps the headline table, five short conclusions and a link. |
Closes #22.
What was done
The README already had 16 October numbers from a single 100,000-album run with no plans and no index comparison, which is what the issue asked for. This replaces them with measurements at 100,000 and 1,000,000 albums, each with the plan PostgreSQL chose, and adds the two comparisons the issue asks for.
Three committed, read-only scripts make it reproducible:
server/scripts/benchmark.sh- runs all checks, refuses any database whose name does not end in_perf(the index comparison takes an exclusive lock onalbum; the livecratedatabase must not be reachable from it). Seeding stays a separate, destructive step so no benchmark runner can wipe a catalog by accident.server/scripts/paging-perf.sql- cursor page vsOFFSETat a configurable depth (-v offset=500000).server/scripts/index-perf.sql- the same selective search with and without the GIN index.DROP INDEXruns inside a transaction that is rolled back, so the index is restored even if the script fails.What was measured
Method: PostgreSQL 18.3, laptop, warm cache, median of five runs (SQL) and three (paging/HTTP). Synthetic data only, in
crate_100k_perfandcrate_perf, nevercrate. Seeding 10 s / 95 s; 41 MB / 350 MB, GIN 11 MB / 75 MB.searchAlbumsrequest with facets and countsFindings I did not paper over
OFFSETbecause it discards before sorting, not because it avoids the scan: there is no index on the sort key, so both still scan and sort. With a composite(lower(title), id)index the cursor page drops to 0.09 ms at 500,000 rows whileOFFSETneeds 164 ms - that 47 MB index is the missing piece, and it is not added here. It needs its own write-cost and bærekraft measurement, so I am filing it as a follow-up issue rather than slipping a migration into a documentation PR.What was tested
Every number comes from the committed scripts, run against fresh databases.
benchmark.shwas run end to end, and deliberately against a database calledcrateto check the guard:pnpm format:check && pnpm lintpasses. The HTTP rows useserver/scripts/search-albums-perf.jsonagainst a production build ofserver/dist/index.js.The numbers are laptop measurements, not VM measurements. The README says so; I am not claiming VM numbers I did not take.